Saltar al contenido principal

6.4 POO avanzada

En esta sección se presentan conceptos avanzados de Programación Orientada a Objetos en Python. No son imprescindibles para empezar, pero ayudan a entender y diseñar sistemas más grandes, como un juego con muchos tipos de personajes, enemigos, objetos y niveles.

La idea es saber que existen estas herramientas, para poder reconocerlas cuando aparezcan en ejemplos reales, librerías o proyectos más grandes.


1️⃣ Clases abstractas (abc module)​

🟩 Qué es una clase abstracta​

Una clase abstracta es una clase que:

  • No se puede instanciar directamente.
  • Sirve como modelo o plantilla para otras clases.
  • Define qué métodos deben existir, pero no necesariamente cómo se implementan.

En un programa, se puede pensar en una clase abstracta como:

“Todas las entidades del juego tendrán ciertos métodos (por ejemplo, actualizar y dibujar), pero cada tipo concreto los implementara a su manera.”

En Python se usan:

  • La clase base ABC (del módulo abc).
  • El decorador @abstractmethod para marcar métodos que deben implementarse en las subclases.

🟦 Ejemplo: Entidad del juego​

Clase abstracta para entidades del juego
from abc import ABC, abstractmethod

class EntidadJuego(ABC):
@abstractmethod
def actualizar(self, dt):
"""Actualiza el estado de la entidad (dt = tiempo transcurrido)."""
pass

@abstractmethod
def dibujar(self):
"""Dibuja la entidad en pantalla."""
pass


class Enemigo(EntidadJuego):
def __init__(self, nombre, vida):
self.nombre = nombre
self.vida = vida

def actualizar(self, dt):
print(f"{self.nombre} se mueve {dt} unidades de tiempo.")

def dibujar(self):
print(f"Dibujando al enemigo {self.nombre} con {self.vida} puntos de vida.")


# e = EntidadJuego() # ❌ Error: no se puede instanciar una clase abstracta

enemigo = Enemigo("Esqueleto", 50)
enemigo.actualizar(0.16)
enemigo.dibujar()
en este ejemplo
  • EntidadJuego define que debe tener una entidad (actualizar, dibujar), pero no el detalle.
  • Enemigo está obligado a implementar esos métodos, o Python no dejará instanciar la clase.
Ventajas:
  • Obliga a que todas las subclases sigan un contrato común.
  • Facilita que otras partes del juego puedan trabajar con colecciones de EntidadJuego sin preocuparse de que tipo concreto es cada una.

2️⃣ Interfaces (enfoque Pythónico)​

🟩 Qué es una interfaz (idea general)​

En muchos lenguajes, una interface es un tipo especial que:

  • Solo declara métodos (nombres y parámetros).
  • No contiene implementación.
  • Obliga a las clases que la implementan a definir esos métodos.

En Python no existe una palabra clave interface, pero se puede conseguir algo muy parecido con:

  • Clases abstractas (ABC) que solo definen métodos abstractos.
  • O bien usando duck typing: “si el objeto tiene los métodos que necesito, me sirve”.

🟦 Ejemplo con ABC: interfaz de guardado de partida​

Interfaz de tipo 'RepositorioPartida'
from abc import ABC, abstractmethod

class RepositorioPartida(ABC):
@abstractmethod
def guardar_partida(self, datos):
"""Guarda los datos de la partida."""
pass

@abstractmethod
def cargar_partida(self, id_partida):
"""Devuelve los datos de una partida a partir de su identificador."""
pass


class RepositorioMemoria(RepositorioPartida):
def __init__(self):
self._datos = {}

def guardar_partida(self, datos):
self._datos[datos["id"]] = datos

def cargar_partida(self, id_partida):
return self._datos.get(id_partida)


class RepositorioArchivo(RepositorioPartida):
def guardar_partida(self, datos):
# Ejemplo simplificado (en un caso real se usaria json y ficheros)
print(f"Guardando partida {datos['id']} en disco...")

def cargar_partida(self, id_partida):
print(f"Cargando partida {id_partida} desde disco...")
return {"id": id_partida, "nivel": 3, "jugador": "Ana"}
en este ejemplo

Cualquier clase que implemente guardar_partida y cargar_partida puede tratarse como un RepositorioPartida, sin importar si guarda los datos en memoria, en ficheros, en una base de datos, etc.

🟨 Interfaces informales: duck typing​

En Python muchas veces ni siquiera hace falta heredar de ABC. Basta con que los objetos tengan los métodos necesarios:

Duck typing para entidades del juego
class EnemigoSimple:
def actualizar(self, dt):
print("Enemigo simple se mueve...")

def dibujar(self):
print("Dibujando enemigo simple")


class NPC:
def actualizar(self, dt):
print("NPC camina por el pueblo...")

def dibujar(self):
print("Dibujando NPC amigable")


def actualizar_y_dibujar(entidades, dt):
for entidad in entidades:
# Solo se asume que cada entidad tiene actualizar() y dibujar()
entidad.actualizar(dt)
entidad.dibujar()


lista_entidades = [EnemigoSimple(), NPC()]
actualizar_y_dibujar(lista_entidades, 0.16)
en este ejemplo

La función actualizar_y_dibujar no necesita saber si está trabajando con un EnemigoSimple, un NPC u otro tipo de entidad: le basta con que tengan los métodos actualizar y dibujar.


3️⃣ Mixins​

🟩 Qué es un Mixin​

Un Mixin es una clase pensada para añadir funcionalidad concreta a otras clases mediante herencia múltiple, sin representar por sí sola un tipo completo.

Ideas clave:

  • Un mixin no suele instanciarse directamente.
  • Normalmente no tiene estado complejo propio (pocos atributos).
  • Se usa para “mezclar” capacidades: PuedeVolar, PuedeDisparar, TieneBarraVida, etc.

En un programa es muy útil para combinar comportamientos:

  • EnemigoVolador que puede volar.
  • EnemigoArtillero que puede disparar.
  • JefeFinal que puede volar y disparar.

🟦 Ejemplo: PuedeVolar y PuedeDisparar​

Ejemplo de mixins en un juego
class PuedeVolarMixin:
def mover(self):
print(f"{self.nombre} se desplaza volando por el escenario.")


class PuedeDispararMixin:
def atacar(self):
print(f"{self.nombre} dispara proyectiles al jugador.")


class EnemigoBase:
def __init__(self, nombre, vida):
self.nombre = nombre
self.vida = vida

def recibir_danio(self, cantidad):
self.vida = max(0, self.vida - cantidad)
print(f"{self.nombre} recibe {cantidad} puntos de danio. Vida: {self.vida}")


class EnemigoVolador(EnemigoBase, PuedeVolarMixin):
pass


class EnemigoArtillero(EnemigoBase, PuedeDispararMixin):
pass


class JefeFinal(EnemigoBase, PuedeVolarMixin, PuedeDispararMixin):
pass


murcielago = EnemigoVolador("Murcielago", 30)
torreta = EnemigoArtillero("Torreta", 80)
dragon = JefeFinal("Dragon", 300)

murcielago.mover()
torreta.atacar()
dragon.mover()
dragon.atacar()
Ventajas de los mixins
  • Permiten reutilizar comportamientos sin duplicar código.
  • Evitan jerarquías de herencia demasiado profundas.
  • Cada mixin representa una capacidad reutilizable.
Cuidado
  • Como usan herencia múltiple, es importante mantener los mixins pequeños y específicos, para no complicar demasiado el diseño.

4️⃣ Dataclasses (@dataclass)​

🟩 Por qué @dataclass​

En muchos programas, incluidas aplicaciones y juegos, hay clases que se usan principalmente para guardar datos, con muy poca lógica:

  • Datos de una dirección (calle, numero, ciudad, cp).
  • Configuracion de una base de datos (host, puerto, usuario).
  • Datos de un artículo (nombre, sku, precio).

El módulo dataclasses (desde Python 3.7) permite definir este tipo de clases de forma muy compacta usando el decorador @dataclass.

Sin @dataclass, una clase de datos típica sería:

class EstadisticasPersonaje:
def __init__(self, vida, ataque, defensa, velocidad):
self.vida = vida
self.ataque = ataque
self.defensa = defensa
self.velocidad = velocidad

def __repr__(self):
return (
f"EstadisticasPersonaje(vida={self.vida}, ataque={self.ataque}, "
f"defensa={self.defensa}, velocidad={self.velocidad})"
)

Con @dataclass todo esto se simplifica.

🟦 Ejemplo básico con @dataclass​

Ejemplo de dataclass para estadisticas de personaje
from dataclasses import dataclass

@dataclass
class EstadisticasPersonaje:
vida: int
ataque: int
defensa: int
velocidad: int = 10 # valor por defecto

stats1 = EstadisticasPersonaje(vida=100, ataque=20, defensa=15)
stats2 = EstadisticasPersonaje(vida=120, ataque=18, defensa=20, velocidad=8)

print(stats1)
print(stats2.vida)
Ventajas
  • @dataclass genera automáticamente:
    • __init__
    • __repr__
    • __eq__ (comparación por valores)
    • y otros métodos utilitarios si se piden.
  • Menos código repetitivo para clases sencillas de datos.
  • Mas legible: la clase se parece a la definicion de una “plantilla de datos”.

🟨 Dataclasses e igualdad​

Como @dataclass genera __eq__, se pueden comparar objetos por sus atributos:

Comparar dataclasses por valores
stats_a = EstadisticasPersonaje(100, 20, 15, 10)
stats_b = EstadisticasPersonaje(100, 20, 15, 10)

print(stats_a == stats_b) # True (mismos valores)
nota

Sin @dataclass, la comparación por defecto sería por identidad de objeto (False salvo que sea el mismo objeto en memoria).

🟧 Dataclasses para configuraciones de nivel​

Dataclass para configuracion de nivel
from dataclasses import dataclass

@dataclass
class ConfigNivel:
id_nivel: int
nombre: str
numero_enemigos: int
duracion_segundos: int
dificultad: str = "normal"

nivel_1 = ConfigNivel(
id_nivel=1,
nombre="Bosque inicial",
numero_enemigos=10,
duracion_segundos=300
)

print(nivel_1)
# ConfigNivel(id_nivel=1, nombre='Bosque inicial',
# numero_enemigos=10, duracion_segundos=300,
# dificultad='normal')
en este ejemplo

Con muy pocas líneas se obtiene una clase clara y fácil de usar para transportar datos por el programa.